'use strict';

const AWS = require('aws-sdk');
const BbPromise = require('bluebird');
const crypto = require('crypto');
const fs = require('fs');
const _ = require('lodash');
const path = require('path');

class AwsCompileFunctions {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;
    const servicePath = this.serverless.config.servicePath || '';
    this.packagePath =
      this.serverless.service.package.path || path.join(servicePath || '.', '.serverless');

    this.provider = this.serverless.getProvider('aws');

    this.ensureTargetExecutionPermission = _.memoize(this.ensureTargetExecutionPermission);
    if (
      this.serverless.service.provider.versionFunctions === undefined ||
      this.serverless.service.provider.versionFunctions === null
    ) {
      this.serverless.service.provider.versionFunctions = true;
    }

    this.hooks = {
      'package:compileFunctions': () =>
        BbPromise.bind(this)
          .then(this.downloadPackageArtifacts)
          .then(this.compileFunctions),
    };
  }

  compileRole(newFunction, role) {
    const compiledFunction = newFunction;
    const unsupportedRoleError = new this.serverless.classes.Error(
      `Unsupported role provided: "${JSON.stringify(role)}"`
    );

    switch (typeof role) {
      case 'object':
        if ('Fn::GetAtt' in role) {
          // role is an "Fn::GetAtt" object
          compiledFunction.Properties.Role = role;
          compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(
            role['Fn::GetAtt'][0]
          );
        } else if ('Fn::ImportValue' in role) {
          // role is an "Fn::ImportValue" object
          compiledFunction.Properties.Role = role;
        } else {
          throw unsupportedRoleError;
        }
        break;
      case 'string':
        if (role.startsWith('arn:aws')) {
          // role is a statically defined iam arn
          compiledFunction.Properties.Role = role;
        } else if (role === 'IamRoleLambdaExecution') {
          // role is the default role generated by the framework
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
        } else {
          // role is a Logical Role Name
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
          compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(role);
        }
        break;
      default:
        throw unsupportedRoleError;
    }
  }

  downloadPackageArtifact(functionName) {
    const { region } = this.options;
    const S3 = new AWS.S3({ region });

    const functionObject = this.serverless.service.getFunction(functionName);
    const artifactFilePath =
      _.get(functionObject, 'package.artifact') ||
      _.get(this, 'serverless.service.package.artifact');

    const regex = new RegExp('s3\\.amazonaws\\.com/(.+)/(.+)');
    const match = artifactFilePath.match(regex);

    if (match) {
      return new BbPromise((resolve, reject) => {
        const tmpDir = this.serverless.utils.getTmpDirPath();
        const filePath = path.join(tmpDir, match[2]);

        const readStream = S3.getObject({
          Bucket: match[1],
          Key: match[2],
        }).createReadStream();

        const writeStream = fs.createWriteStream(filePath);

        readStream.on('error', error => reject(error));
        readStream
          .pipe(writeStream)
          .on('error', reject)
          .on('close', () => {
            if (functionObject.package.artifact) {
              functionObject.package.artifact = filePath;
            } else {
              this.serverless.service.package.artifact = filePath;
            }
            return resolve(filePath);
          });
      });
    }

    return BbPromise.resolve();
  }

  compileFunction(functionName) {
    return BbPromise.try(() => {
      const cfTemplate = this.serverless.service.provider.compiledCloudFormationTemplate;
      const functionResource = this.cfLambdaFunctionTemplate();
      const functionObject = this.serverless.service.getFunction(functionName);
      functionObject.package = functionObject.package || {};

      const serviceArtifactFileName = this.provider.naming.getServiceArtifactName();
      const functionArtifactFileName = this.provider.naming.getFunctionArtifactName(functionName);

      let artifactFilePath =
        functionObject.package.artifact || this.serverless.service.package.artifact;

      if (
        !artifactFilePath ||
        (this.serverless.service.artifact && !functionObject.package.artifact)
      ) {
        let artifactFileName = serviceArtifactFileName;
        if (this.serverless.service.package.individually || functionObject.package.individually) {
          artifactFileName = functionArtifactFileName;
        }

        artifactFilePath = path.join(
          this.serverless.config.servicePath,
          '.serverless',
          artifactFileName
        );
      }

      if (this.serverless.service.package.deploymentBucket) {
        functionResource.Properties.Code.S3Bucket = this.serverless.service.package.deploymentBucket;
      }

      const s3Folder = this.serverless.service.package.artifactDirectoryName;
      const s3FileName = artifactFilePath.split(path.sep).pop();
      functionResource.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`;

      if (!functionObject.handler) {
        const errorMessage = [
          `Missing "handler" property in function "${functionName}".`,
          ' Please make sure you point to the correct lambda handler.',
          ' For example: handler.hello.',
          ' Please check the docs for more info',
        ].join('');
        throw new this.serverless.classes.Error(errorMessage);
      }

      const Handler = functionObject.handler;
      const FunctionName = functionObject.name;
      const MemorySize =
        Number(functionObject.memorySize) ||
        Number(this.serverless.service.provider.memorySize) ||
        1024;
      const Timeout =
        Number(functionObject.timeout) || Number(this.serverless.service.provider.timeout) || 6;
      const Runtime = this.provider.getRuntime(functionObject.runtime);

      functionResource.Properties.Handler = Handler;
      functionResource.Properties.FunctionName = FunctionName;
      functionResource.Properties.MemorySize = MemorySize;
      functionResource.Properties.Timeout = Timeout;
      functionResource.Properties.Runtime = Runtime;

      // publish these properties to the platform
      this.serverless.service.functions[functionName].memory = MemorySize;
      this.serverless.service.functions[functionName].timeout = Timeout;
      this.serverless.service.functions[functionName].runtime = Runtime;

      if (functionObject.description) {
        functionResource.Properties.Description = functionObject.description;
      }

      if (functionObject.condition) {
        functionResource.Condition = functionObject.condition;
      }

      if (functionObject.dependsOn) {
        functionResource.DependsOn = (functionResource.DependsOn || []).concat(
          functionObject.dependsOn
        );
      }

      if (functionObject.tags || this.serverless.service.provider.tags) {
        const tags = Object.assign({}, this.serverless.service.provider.tags, functionObject.tags);
        functionResource.Properties.Tags = [];
        _.forEach(tags, (Value, Key) => {
          functionResource.Properties.Tags.push({ Key, Value });
        });
      }

      if (functionObject.onError) {
        const arn = functionObject.onError;

        if (typeof arn === 'string') {
          const splittedArn = arn.split(':');
          if (splittedArn[0] === 'arn' && (splittedArn[2] === 'sns' || splittedArn[2] === 'sqs')) {
            const dlqType = splittedArn[2];
            const iamRoleLambdaExecution = cfTemplate.Resources.IamRoleLambdaExecution;
            let stmt;

            functionResource.Properties.DeadLetterConfig = {
              TargetArn: arn,
            };

            if (dlqType === 'sns') {
              stmt = {
                Effect: 'Allow',
                Action: ['sns:Publish'],
                Resource: [arn],
              };
            } else if (dlqType === 'sqs') {
              const errorMessage = [
                'onError currently only supports SNS topic arns due to a',
                ' race condition when using SQS queue arns and updating the IAM role.',
                ' Please check the docs for more info.',
              ].join('');
              throw new this.serverless.classes.Error(errorMessage);
            }

            // update the PolicyDocument statements (if default policy is used)
            if (iamRoleLambdaExecution) {
              iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push(stmt);
            }
          } else {
            const errorMessage = 'onError config must be a SNS topic arn or SQS queue arn';
            throw new this.serverless.classes.Error(errorMessage);
          }
        } else if (this.isArnRefGetAttOrImportValue(arn)) {
          functionResource.Properties.DeadLetterConfig = {
            TargetArn: arn,
          };
        } else {
          const errorMessage = [
            'onError config must be provided as an arn string,',
            ' Ref, Fn::GetAtt or Fn::ImportValue',
          ].join('');
          throw new this.serverless.classes.Error(errorMessage);
        }
      }

      let kmsKeyArn;
      const serviceObj = this.serverless.service.serviceObject;
      if ('awsKmsKeyArn' in functionObject) {
        kmsKeyArn = functionObject.awsKmsKeyArn;
      } else if (serviceObj && 'awsKmsKeyArn' in serviceObj) {
        kmsKeyArn = serviceObj.awsKmsKeyArn;
      }

      if (kmsKeyArn) {
        const arn = kmsKeyArn;

        if (typeof arn === 'string') {
          const splittedArn = arn.split(':');
          if (splittedArn[0] === 'arn' && splittedArn[2] === 'kms') {
            const iamRoleLambdaExecution = cfTemplate.Resources.IamRoleLambdaExecution;

            functionResource.Properties.KmsKeyArn = arn;

            const stmt = {
              Effect: 'Allow',
              Action: ['kms:Decrypt'],
              Resource: [arn],
            };

            // update the PolicyDocument statements (if default policy is used)
            if (iamRoleLambdaExecution) {
              iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
                iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
                [stmt],
                _.isEqual
              );
            }
          } else {
            const errorMessage = 'awsKmsKeyArn config must be a KMS key arn';
            throw new this.serverless.classes.Error(errorMessage);
          }
        } else if (this.isArnRefGetAttOrImportValue(arn)) {
          functionResource.Properties.KmsKeyArn = arn;
        } else {
          const errorMessage = [
            'awsKmsKeyArn config must be provided as an arn string,',
            ' Ref, Fn::GetAtt or Fn::ImportValue',
          ].join('');
          throw new this.serverless.classes.Error(errorMessage);
        }
      }

      const tracing =
        functionObject.tracing ||
        (this.serverless.service.provider.tracing &&
          this.serverless.service.provider.tracing.lambda);

      if (tracing) {
        if (typeof tracing === 'boolean' || typeof tracing === 'string') {
          let mode = tracing;

          if (typeof tracing === 'boolean') {
            mode = 'Active';
          }

          const iamRoleLambdaExecution = cfTemplate.Resources.IamRoleLambdaExecution;

          functionResource.Properties.TracingConfig = {
            Mode: mode,
          };

          const stmt = {
            Effect: 'Allow',
            Action: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'],
            Resource: ['*'],
          };

          // update the PolicyDocument statements (if default policy is used)
          if (iamRoleLambdaExecution) {
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
              iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
              [stmt],
              _.isEqual
            );
          }
        } else {
          const errorMessage =
            'tracing requires a boolean value or the "mode" provided as a string';
          throw new this.serverless.classes.Error(errorMessage);
        }
      }

      if (functionObject.environment || this.serverless.service.provider.environment) {
        functionResource.Properties.Environment = {};
        functionResource.Properties.Environment.Variables = Object.assign(
          {},
          this.serverless.service.provider.environment,
          functionObject.environment
        );

        let invalidEnvVar = null;
        Object.keys(functionResource.Properties.Environment.Variables).forEach(key => {
          // taken from the bash man pages
          if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
            invalidEnvVar = `Invalid characters in environment variable ${key}`;
            return false; // break loop with lodash
          }
          const value = functionResource.Properties.Environment.Variables[key];
          if (_.isObject(value)) {
            const isCFRef =
              _.isObject(value) && !_.some(value, (v, k) => k !== 'Ref' && !k.startsWith('Fn::'));
            if (!isCFRef) {
              invalidEnvVar = `Environment variable ${key} must contain string`;
              return false;
            }
          }
          return true;
        });

        if (invalidEnvVar) throw new this.serverless.classes.Error(invalidEnvVar);
      }

      if ('role' in functionObject) {
        this.compileRole(functionResource, functionObject.role);
      } else if ('role' in this.serverless.service.provider) {
        this.compileRole(functionResource, this.serverless.service.provider.role);
      } else {
        this.compileRole(functionResource, 'IamRoleLambdaExecution');
      }

      if (!functionObject.vpc) functionObject.vpc = {};
      if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {};

      functionResource.Properties.VpcConfig = {
        SecurityGroupIds:
          functionObject.vpc.securityGroupIds ||
          this.serverless.service.provider.vpc.securityGroupIds,
        SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds,
      };

      if (
        !functionResource.Properties.VpcConfig.SecurityGroupIds ||
        !functionResource.Properties.VpcConfig.SubnetIds
      ) {
        delete functionResource.Properties.VpcConfig;
      }

      if (functionObject.reservedConcurrency || functionObject.reservedConcurrency === 0) {
        // Try convert reservedConcurrency to integer
        const reservedConcurrency = Number(functionObject.reservedConcurrency);

        if (_.isInteger(reservedConcurrency)) {
          functionResource.Properties.ReservedConcurrentExecutions = reservedConcurrency;
        } else {
          const errorMessage = [
            'You should use integer as reservedConcurrency value on function: ',
            `${functionResource.Properties.FunctionName}`,
          ].join('');

          throw new this.serverless.classes.Error(errorMessage);
        }
      }

      if (!functionObject.disableLogs) {
        functionResource.DependsOn = [
          this.provider.naming.getLogGroupLogicalId(functionName),
        ].concat(functionResource.DependsOn || []);
      }

      if (functionObject.layers && Array.isArray(functionObject.layers)) {
        functionResource.Properties.Layers = functionObject.layers;
      } else if (
        this.serverless.service.provider.layers &&
        Array.isArray(this.serverless.service.provider.layers)
      ) {
        functionResource.Properties.Layers = this.serverless.service.provider.layers;
      }

      const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName);
      const newFunctionObject = {
        [functionLogicalId]: functionResource,
      };

      Object.assign(cfTemplate.Resources, newFunctionObject);

      const shouldVersionFunction =
        functionObject.versionFunction != null
          ? functionObject.versionFunction
          : this.serverless.service.provider.versionFunctions;

      let versionCompilationPromise;
      if (shouldVersionFunction || functionObject.provisionedConcurrency) {
        // Create hashes for the artifact and the logical id of the version resource
        // The one for the version resource must include the function configuration
        // to make sure that a new version is created on configuration changes and
        // not only on source changes.
        const fileHash = crypto.createHash('sha256');
        const versionHash = crypto.createHash('sha256');
        fileHash.setEncoding('base64');
        versionHash.setEncoding('base64');

        // Read the file in chunks and add them to the hash (saves memory and performance)
        versionCompilationPromise = BbPromise.fromCallback(cb => {
          const readStream = fs.createReadStream(artifactFilePath);

          readStream
            .on('data', chunk => {
              fileHash.write(chunk);
              versionHash.write(chunk);
            })
            .on('close', () => {
              cb();
            })
            .on('error', error => {
              cb(error);
            });
        }).then(() => {
          // Include function configuration in version id hash (without the Code part)
          const properties = _.omit(_.get(functionResource, 'Properties', {}), 'Code');
          _.forOwn(properties, value => {
            const hashedValue = _.isObject(value) ? JSON.stringify(value) : _.toString(value);
            versionHash.write(hashedValue);
          });

          // Finalize hashes
          fileHash.end();
          versionHash.end();

          const fileDigest = fileHash.read();
          const versionDigest = versionHash.read();

          const versionResource = this.cfLambdaVersionTemplate();

          versionResource.Properties.CodeSha256 = fileDigest;
          versionResource.Properties.FunctionName = { Ref: functionLogicalId };
          if (functionObject.description) {
            versionResource.Properties.Description = functionObject.description;
          }

          // use the version SHA in the logical resource ID of the version because
          // AWS::Lambda::Version resource will not support updates
          const versionLogicalId = this.provider.naming.getLambdaVersionLogicalId(
            functionName,
            versionDigest
          );
          functionObject.versionLogicalId = versionLogicalId;
          const newVersionObject = {
            [versionLogicalId]: versionResource,
          };

          Object.assign(cfTemplate.Resources, newVersionObject);

          // Add function versions to Outputs section
          const functionVersionOutputLogicalId = this.provider.naming.getLambdaVersionOutputLogicalId(
            functionName
          );
          const newVersionOutput = this.cfOutputLatestVersionTemplate();

          newVersionOutput.Value = { Ref: versionLogicalId };

          Object.assign(cfTemplate.Outputs, {
            [functionVersionOutputLogicalId]: newVersionOutput,
          });

          if (!functionObject.provisionedConcurrency) return;
          if (!shouldVersionFunction) delete versionResource.DeletionPolicy;

          const provisionedConcurrency = Number(functionObject.provisionedConcurrency);

          if (!_.isInteger(provisionedConcurrency)) {
            throw new this.serverless.classes.Error(
              'You should use integer as provisionedConcurrency value on function: ' +
                `${functionResource.Properties.FunctionName}`
            );
          }
          const aliasLogicalId = this.provider.naming.getLambdaProvisionedConcurrencyAliasLogicalId(
            functionName
          );
          const aliasName = this.provider.naming.getLambdaProvisionedConcurrencyAliasName();

          functionObject.targetAlias = { name: aliasName, logicalId: aliasLogicalId };

          const aliasResource = {
            Type: 'AWS::Lambda::Alias',
            Properties: {
              FunctionName: { Ref: functionLogicalId },
              FunctionVersion: { 'Fn::GetAtt': [versionLogicalId, 'Version'] },
              Name: aliasName,
              ProvisionedConcurrencyConfig: {
                ProvisionedConcurrentExecutions: provisionedConcurrency,
              },
            },
            DependsOn: functionLogicalId,
          };

          cfTemplate.Resources[aliasLogicalId] = aliasResource;
        });
      } else {
        versionCompilationPromise = BbPromise.resolve();
      }

      return versionCompilationPromise.then(() => this.compileFunctionDestinations(functionName));
    });
  }

  compileFunctionDestinations(functionName) {
    const functionObject = this.serverless.service.getFunction(functionName);
    const { destinations } = functionObject;
    if (!destinations) return;

    const cfResources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources;
    const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName);
    const resource = {
      Type: 'AWS::Lambda::EventInvokeConfig',
      Properties: {
        FunctionName: { Ref: functionLogicalId },
        DestinationConfig: {},
        MaximumEventAgeInSeconds: destinations.maximumEventAge,
        MaximumRetryAttempts: destinations.maximumRetryAttempts,
        Qualifier: functionObject.targetAlias ? functionObject.targetAlias.name : '$LATEST',
      },
    };
    const destinationConfig = resource.Properties.DestinationConfig;

    const hasAccessPoliciesHandledExternally = Boolean(
      functionObject.role || this.serverless.service.provider.role
    );
    if (destinations.onSuccess) {
      if (!hasAccessPoliciesHandledExternally) {
        this.ensureTargetExecutionPermission(destinations.onSuccess);
      }
      destinationConfig.OnSuccess = {
        Destination: destinations.onSuccess.startsWith('arn:')
          ? destinations.onSuccess
          : this.provider.resolveFunctionArn(destinations.onSuccess),
      };
    }
    if (destinations.onFailure) {
      if (!hasAccessPoliciesHandledExternally) {
        this.ensureTargetExecutionPermission(destinations.onFailure);
      }
      destinationConfig.OnFailure = {
        Destination: destinations.onFailure.startsWith('arn:')
          ? destinations.onFailure
          : this.provider.resolveFunctionArn(destinations.onFailure),
      };
    }

    cfResources[this.provider.naming.getLambdaEventConfigLogicalId(functionName)] = resource;
  }

  // Memoized in a construtor
  ensureTargetExecutionPermission(functionAddress) {
    const iamPolicyStatements = this.serverless.service.provider.compiledCloudFormationTemplate
      .Resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement;

    const action = (() => {
      if (!functionAddress.startsWith('arn:') || functionAddress.includes(':function:')) {
        return 'lambda:InvokeFunction';
      }
      if (functionAddress.includes(':sqs:')) return 'sqs:SendMessage';
      if (functionAddress.includes(':sns:')) return 'sns:Publish';
      if (functionAddress.includes(':event-bus/')) return 'events:PutEvents';
      throw new this.serverless.classes.Error(`Unsupported destination target ${functionAddress}`);
    })();
    iamPolicyStatements.push({
      Effect: 'Allow',
      Action: action,
      // Note: Cannot address function via { 'Fn::GetAtt': [targetLogicalId, 'Arn'] }
      // as same IAM settings are used for target function and that will introduce
      // circular dependency error. Relying on Fn::Sub as a workaround
      Resource: functionAddress.startsWith('arn:')
        ? functionAddress
        : {
            'Fn::Sub': `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${
              this.serverless.service.getFunction(functionAddress).name
            }`,
          },
    });
  }

  downloadPackageArtifacts() {
    const allFunctions = this.serverless.service.getAllFunctions();
    return BbPromise.each(allFunctions, functionName => this.downloadPackageArtifact(functionName));
  }

  compileFunctions() {
    const allFunctions = this.serverless.service.getAllFunctions();
    return BbPromise.each(allFunctions, functionName => this.compileFunction(functionName));
  }

  // helper functions
  isArnRefGetAttOrImportValue(arn) {
    return (
      typeof arn === 'object' &&
      _.some(Object.keys(arn), k => ['Ref', 'Fn::GetAtt', 'Fn::ImportValue'].includes(k))
    );
  }

  cfLambdaFunctionTemplate() {
    return {
      Type: 'AWS::Lambda::Function',
      Properties: {
        Code: {
          S3Bucket: {
            Ref: 'ServerlessDeploymentBucket',
          },
          S3Key: 'S3Key',
        },
        FunctionName: 'FunctionName',
        Handler: 'Handler',
        MemorySize: 'MemorySize',
        Role: 'Role',
        Runtime: 'Runtime',
        Timeout: 'Timeout',
      },
    };
  }

  cfLambdaVersionTemplate() {
    return {
      Type: 'AWS::Lambda::Version',
      // Retain old versions even though they will not be in future
      // CloudFormation stacks. On stack delete, these will be removed when
      // their associated function is removed.
      DeletionPolicy: 'Retain',
      Properties: {
        FunctionName: 'FunctionName',
        CodeSha256: 'CodeSha256',
      },
    };
  }

  cfOutputLatestVersionTemplate() {
    return {
      Description: 'Current Lambda function version',
      Value: 'Value',
    };
  }
}

module.exports = AwsCompileFunctions;
